/**
 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
import { TreeWalker, getFillerOffset } from 'ckeditor5/src/engine';
import { ButtonView } from 'ckeditor5/src/ui';
/**
 * Creates a list item {@link module:engine/view/containerelement~ContainerElement}.
 *
 * @param writer The writer instance.
 */
export function createViewListItemElement(writer) {
    const viewItem = writer.createContainerElement('li');
    viewItem.getFillerOffset = getListItemFillerOffset;
    return viewItem;
}
/**
 * Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of the given `modelItem` model `listItem` element.
 * Then, it binds the created view list item (`<li>`) with the model `listItem` element.
 * The function then returns the created view list item (`<li>`).
 *
 * @param modelItem Model list item.
 * @param conversionApi Conversion interface.
 * @returns View list element.
 */
export function generateLiInUl(modelItem, conversionApi) {
    const mapper = conversionApi.mapper;
    const viewWriter = conversionApi.writer;
    const listType = modelItem.getAttribute('listType') == 'numbered' ? 'ol' : 'ul';
    const viewItem = createViewListItemElement(viewWriter);
    const viewList = viewWriter.createContainerElement(listType, null);
    viewWriter.insert(viewWriter.createPositionAt(viewList, 0), viewItem);
    mapper.bindElements(modelItem, viewItem);
    return viewItem;
}
/**
 * Helper function that inserts a view list at a correct place and merges it with its siblings.
 * It takes a model list item element (`modelItem`) and a corresponding view list item element (`injectedItem`). The view list item
 * should be in a view list element (`<ul>` or `<ol>`) and should be its only child.
 * See comments below to better understand the algorithm.
 *
 * @param modelItem Model list item.
 * @param injectedItem
 * @param conversionApi Conversion interface.
 * @param model The model instance.
 */
export function injectViewList(modelItem, injectedItem, conversionApi, model) {
    const injectedList = injectedItem.parent;
    const mapper = conversionApi.mapper;
    const viewWriter = conversionApi.writer;
    // The position where the view list will be inserted.
    let insertPosition = mapper.toViewPosition(model.createPositionBefore(modelItem));
    // 1. Find the previous list item that has the same or smaller indent. Basically we are looking for the first model item
    // that is a "parent" or "sibling" of the injected model item.
    // If there is no such list item, it means that the injected list item is the first item in "its list".
    const refItem = getSiblingListItem(modelItem.previousSibling, {
        sameIndent: true,
        smallerIndent: true,
        listIndent: modelItem.getAttribute('listIndent')
    });
    const prevItem = modelItem.previousSibling;
    if (refItem && refItem.getAttribute('listIndent') == modelItem.getAttribute('listIndent')) {
        // There is a list item with the same indent - we found the same-level sibling.
        // Break the list after it. The inserted view item will be added in the broken space.
        const viewItem = mapper.toViewElement(refItem);
        insertPosition = viewWriter.breakContainer(viewWriter.createPositionAfter(viewItem));
    }
    else {
        // There is no list item with the same indent. Check the previous model item.
        if (prevItem && prevItem.name == 'listItem') {
            // If it is a list item, it has to have a lower indent.
            // It means that the inserted item should be added to it as its nested item.
            insertPosition = mapper.toViewPosition(model.createPositionAt(prevItem, 'end'));
            // There could be some not mapped elements (eg. span in to-do list) but we need to insert
            // a nested list directly inside the li element.
            const mappedViewAncestor = mapper.findMappedViewAncestor(insertPosition);
            const nestedList = findNestedList(mappedViewAncestor);
            // If there already is some nested list, then use it's position.
            if (nestedList) {
                insertPosition = viewWriter.createPositionBefore(nestedList);
            }
            else {
                // Else just put new list on the end of list item content.
                insertPosition = viewWriter.createPositionAt(mappedViewAncestor, 'end');
            }
        }
        else {
            // The previous item is not a list item (or does not exist at all).
            // Just map the position and insert the view item at the mapped position.
            insertPosition = mapper.toViewPosition(model.createPositionBefore(modelItem));
        }
    }
    insertPosition = positionAfterUiElements(insertPosition);
    // Insert the view item.
    viewWriter.insert(insertPosition, injectedList);
    // 2. Handle possible children of the injected model item.
    if (prevItem && prevItem.name == 'listItem') {
        const prevView = mapper.toViewElement(prevItem);
        const walkerBoundaries = viewWriter.createRange(viewWriter.createPositionAt(prevView, 0), insertPosition);
        const walker = walkerBoundaries.getWalker({ ignoreElementEnd: true });
        for (const value of walker) {
            if (value.item.is('element', 'li')) {
                const breakPosition = viewWriter.breakContainer(viewWriter.createPositionBefore(value.item));
                const viewList = value.item.parent;
                const targetPosition = viewWriter.createPositionAt(injectedItem, 'end');
                mergeViewLists(viewWriter, targetPosition.nodeBefore, targetPosition.nodeAfter);
                viewWriter.move(viewWriter.createRangeOn(viewList), targetPosition);
                // This is bad, but those lists will be removed soon anyway.
                walker._position = breakPosition;
            }
        }
    }
    else {
        const nextViewList = injectedList.nextSibling;
        if (nextViewList && (nextViewList.is('element', 'ul') || nextViewList.is('element', 'ol'))) {
            let lastSubChild = null;
            for (const child of nextViewList.getChildren()) {
                const modelChild = mapper.toModelElement(child);
                if (modelChild &&
                    modelChild.getAttribute('listIndent') > modelItem.getAttribute('listIndent')) {
                    lastSubChild = child;
                }
                else {
                    break;
                }
            }
            if (lastSubChild) {
                viewWriter.breakContainer(viewWriter.createPositionAfter(lastSubChild));
                viewWriter.move(viewWriter.createRangeOn(lastSubChild.parent), viewWriter.createPositionAt(injectedItem, 'end'));
            }
        }
    }
    // Merge the inserted view list with its possible neighbor lists.
    mergeViewLists(viewWriter, injectedList, injectedList.nextSibling);
    mergeViewLists(viewWriter, injectedList.previousSibling, injectedList);
}
export function mergeViewLists(viewWriter, firstList, secondList) {
    // Check if two lists are going to be merged.
    if (!firstList || !secondList || (firstList.name != 'ul' && firstList.name != 'ol')) {
        return null;
    }
    // Both parameters are list elements, so compare types now.
    if (firstList.name != secondList.name || firstList.getAttribute('class') !== secondList.getAttribute('class')) {
        return null;
    }
    return viewWriter.mergeContainers(viewWriter.createPositionAfter(firstList));
}
/**
 * Helper function that for a given `view.Position`, returns a `view.Position` that is after all `view.UIElement`s that
 * are after the given position.
 *
 * For example:
 * `<container:p>foo^<ui:span></ui:span><ui:span></ui:span>bar</container:p>`
 * For position ^, the position before "bar" will be returned.
 *
 */
export function positionAfterUiElements(viewPosition) {
    return viewPosition.getLastMatchingPosition(value => value.item.is('uiElement'));
}
/**
 * Helper function that searches for a previous list item sibling of a given model item that meets the given criteria
 * passed by the options object.
 *
 * @param options Search criteria.
 * @param options.sameIndent Whether the sought sibling should have the same indentation.
 * @param options.smallerIndent Whether the sought sibling should have a smaller indentation.
 * @param options.listIndent The reference indentation.
 * @param options.direction Walking direction.
 */
export function getSiblingListItem(modelItem, options) {
    const sameIndent = !!options.sameIndent;
    const smallerIndent = !!options.smallerIndent;
    const indent = options.listIndent;
    let item = modelItem;
    while (item && item.name == 'listItem') {
        const itemIndent = item.getAttribute('listIndent');
        if ((sameIndent && indent == itemIndent) || (smallerIndent && indent > itemIndent)) {
            return item;
        }
        if (options.direction === 'forward') {
            item = item.nextSibling;
        }
        else {
            item = item.previousSibling;
        }
    }
    return null;
}
/**
 * Helper method for creating a UI button and linking it with an appropriate command.
 *
 * @internal
 * @param editor The editor instance to which the UI component will be added.
 * @param commandName The name of the command.
 * @param label The button label.
 * @param icon The source of the icon.
 */
export function createUIComponent(editor, commandName, label, icon) {
    editor.ui.componentFactory.add(commandName, locale => {
        const command = editor.commands.get(commandName);
        const buttonView = new ButtonView(locale);
        buttonView.set({
            label,
            icon,
            tooltip: true,
            isToggleable: true
        });
        // Bind button model to command.
        buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');
        // Execute command.
        buttonView.on('execute', () => {
            editor.execute(commandName);
            editor.editing.view.focus();
        });
        return buttonView;
    });
}
/**
 * Returns a first list view element that is direct child of the given view element.
 */
export function findNestedList(viewElement) {
    for (const node of viewElement.getChildren()) {
        if (node.name == 'ul' || node.name == 'ol') {
            return node;
        }
    }
    return null;
}
/**
 * Returns an array with all `listItem` elements that represent the same list.
 *
 * It means that values of `listIndent`, `listType`, `listStyle`, `listReversed` and `listStart` for all items are equal.
 *
 * Additionally, if the `position` is inside a list item, that list item will be returned as well.
 *
 * @param position Starting position.
 * @param direction Walking direction.
 */
export function getSiblingNodes(position, direction) {
    const items = [];
    const listItem = position.parent;
    const walkerOptions = {
        ignoreElementEnd: false,
        startPosition: position,
        shallow: true,
        direction
    };
    const limitIndent = listItem.getAttribute('listIndent');
    const nodes = [...new TreeWalker(walkerOptions)]
        .filter(value => value.item.is('element'))
        .map(value => value.item);
    for (const element of nodes) {
        // If found something else than `listItem`, we're out of the list scope.
        if (!element.is('element', 'listItem')) {
            break;
        }
        // If current parsed item has lower indent that element that the element that was a starting point,
        // it means we left a nested list. Abort searching items.
        //
        // ■ List item 1.       [listIndent=0]
        //     ○ List item 2.[] [listIndent=1], limitIndent = 1,
        //     ○ List item 3.   [listIndent=1]
        // ■ List item 4.       [listIndent=0]
        //
        // Abort searching when leave nested list.
        if (element.getAttribute('listIndent') < limitIndent) {
            break;
        }
        // ■ List item 1.[]     [listIndent=0] limitIndent = 0,
        //     ○ List item 2.   [listIndent=1]
        //     ○ List item 3.   [listIndent=1]
        // ■ List item 4.       [listIndent=0]
        //
        // Ignore nested lists.
        if (element.getAttribute('listIndent') > limitIndent) {
            continue;
        }
        // ■ List item 1.[]  [listType=bulleted]
        // 1. List item 2.   [listType=numbered]
        // 2.List item 3.    [listType=numbered]
        //
        // Abort searching when found a different kind of a list.
        if (element.getAttribute('listType') !== listItem.getAttribute('listType')) {
            break;
        }
        // ■ List item 1.[]  [listType=bulleted]
        // ■ List item 2.    [listType=bulleted]
        // ○ List item 3.    [listType=bulleted]
        // ○ List item 4.    [listType=bulleted]
        //
        // Abort searching when found a different list style,
        if (element.getAttribute('listStyle') !== listItem.getAttribute('listStyle')) {
            break;
        }
        // ... different direction
        if (element.getAttribute('listReversed') !== listItem.getAttribute('listReversed')) {
            break;
        }
        // ... and different start index
        if (element.getAttribute('listStart') !== listItem.getAttribute('listStart')) {
            break;
        }
        if (direction === 'backward') {
            items.unshift(element);
        }
        else {
            items.push(element);
        }
    }
    return items;
}
/**
 * Returns an array with all `listItem` elements in the model selection.
 *
 * It returns all the items even if only a part of the list is selected, including items that belong to nested lists.
 * If no list is selected, it returns an empty array.
 * The order of the elements is not specified.
 *
 * @internal
 */
export function getSelectedListItems(model) {
    const document = model.document;
    // For all selected blocks find all list items that are being selected
    // and update the `listStyle` attribute in those lists.
    let listItems = [...document.selection.getSelectedBlocks()]
        .filter(element => element.is('element', 'listItem'))
        .map(element => {
        const position = model.change(writer => writer.createPositionAt(element, 0));
        return [
            ...getSiblingNodes(position, 'backward'),
            ...getSiblingNodes(position, 'forward')
        ];
    })
        .flat();
    // Since `getSelectedBlocks()` can return items that belong to the same list, and
    // `getSiblingNodes()` returns the entire list, we need to remove duplicated items.
    listItems = [...new Set(listItems)];
    return listItems;
}
const BULLETED_LIST_STYLE_TYPES = ['disc', 'circle', 'square'];
// There's a lot of them (https://www.w3.org/TR/css-counter-styles-3/#typedef-counter-style).
// Let's support only those that can be selected by ListPropertiesUI.
const NUMBERED_LIST_STYLE_TYPES = [
    'decimal',
    'decimal-leading-zero',
    'lower-roman',
    'upper-roman',
    'lower-latin',
    'upper-latin'
];
/**
 * Checks whether the given list-style-type is supported by numbered or bulleted list.
 */
export function getListTypeFromListStyleType(listStyleType) {
    if (BULLETED_LIST_STYLE_TYPES.includes(listStyleType)) {
        return 'bulleted';
    }
    if (NUMBERED_LIST_STYLE_TYPES.includes(listStyleType)) {
        return 'numbered';
    }
    return null;
}
/**
 * Implementation of getFillerOffset for view list item element.
 *
 * @returns Block filler offset or `null` if block filler is not needed.
 */
function getListItemFillerOffset() {
    const hasOnlyLists = !this.isEmpty && (this.getChild(0).name == 'ul' || this.getChild(0).name == 'ol');
    if (this.isEmpty || hasOnlyLists) {
        return 0;
    }
    return getFillerOffset.call(this);
}
